<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.orgs
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\models;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
/**
* This is class is used to handle
* db results needed for a user to sign in
*
* @author Chris Pollett
*/
class SigninModel extends Model
{
/**
* The line marking where a ballot's sealed witness shares begin in
* the file its round waits in, in the same style as the sections
* already there
* @var string
*/
const BALLOT_SHARES_MARK = "-----SHARES-----\n";
/**
* The line marking where a witness round's public key and the access
* time of its key file are kept in the file that round waits in
* @var string
*/
const BALLOT_ROUND_MARK = "-----ROUND-----\n";
/**
* How far back, in seconds, a witness round's key file has its access
* time set when the round opens. It has to be far enough behind the
* file's change time that a file system which moves an access time
* only when it lags will move this one on the first read; a day
* clears every such rule seen.
* @var int
*/
const KEY_FILE_READ_WINDOW = 86400;
/**
* Why a link sent to a witness is refused: that witness has already
* taken their part, whether at the form or by following the link
* before.
* @var string
*/
const LINK_TAKEN = "already taken";
/**
* Why a link sent to a witness is refused: the link has expired, was
* not signed by this site, or belongs to a set of invitations a
* fresher set has replaced.
* @var string
*/
const LINK_BAD = "no longer good";
/**
* Why a link sent to a witness is refused: the day it was good for
* has passed. Told apart from a link that does not match, so a
* witness who sat on their mail is not left wondering whether
* somebody tampered with it.
* @var string
*/
const LINK_EXPIRED = "run out";
/**
* What counting a ballot says when the key rebuilt from the
* witnesses' passwords is not the one the ballot was made with, so
* the votes cannot be opened. Named here because a screen that
* looked for a different wording than this reported a count that
* had failed as one that had worked.
* @var string
*/
const KEY_MISMATCH = "KEY_MISMATCH";
/**
* The line marking where a ballot's invitation nonce is kept in the
* file its round waits in. Sending a fresh set of invitations writes
* a new one, which is what ends the set before it.
* @var string
*/
const BALLOT_NONCE_MARK = "-----INVITE-----\n";
/**
* The line marking where the round file records when each witness
* was last written to, so a request that arrives twice does not
* send a second invitation and end the first one.
* @var string
*/
const BALLOT_INVITED_MARK = "-----INVITED-----\n";
/**
* Checks that a username password pair is valid. This function
* is slow because the underlying crypt to slow
*
* @param string &$username the username to check - username might
* be changed to a local username if LDAP being used
* @param string $password the password to check
* @return bool where the password is that of the given user
* (or at least hashes to the same thing)
*/
public function checkValidSignin(&$username, $password)
{
$valid_password = false;
if (C\p('AUTH_METHOD') == C\LDAP_AUTHENTICATION &&
empty($this->ldapConfigProblems(C\p('LDAP_CONTROLLERS'),
C\p('LDAP_ACCOUNT_SUFFIX'), C\p('LDAP_BASE_DN')))) {
/*
LDAP is the chosen method and nothing is left to fix, so the
directory owns passwords: a member is authenticated by
binding to the directory and then mapping the email the
directory holds back to the Yioop account that owns it.
There is no local password fallback on this path. When LDAP
is chosen but not yet ready (a missing setting, a shared
email, or a root account with no email), this branch is
skipped and the local-password check below runs instead, so
the site keeps working while an operator finishes the setup.
*/
$controllers = (is_array(C\p('LDAP_CONTROLLERS'))) ?
C\p('LDAP_CONTROLLERS') : array_values(array_filter(
array_map('trim', explode(',',
(string)C\p('LDAP_CONTROLLERS')))));
$domain_controller =
$controllers[rand(0, count($controllers) - 1)];
$account_suffix = C\p('LDAP_ACCOUNT_SUFFIX');
if ($connection = ldap_connect("ldaps://" .
$domain_controller)) {
ldap_start_tls($connection);
$ldap_name = $_SESSION["LDAP_NAME"][$username] ?? $username;
if (ldap_bind($connection, $ldap_name . $account_suffix,
$password)) {
$email = "";
$fields = ["mail"];
$filter = "(&(objectCategory=person)(samaccountname=" .
$ldap_name . "))";
if ($results = ldap_search($connection,
C\p('LDAP_BASE_DN'), $filter, $fields)) {
$entries = ldap_get_entries($connection, $results);
$email = $entries[0]['mail'][0] ?? "";
}
$local_user = $this->localUserForLdapEmail($email);
if (!empty($local_user)) {
$username = $local_user;
$valid_password = true;
$_SESSION["LDAP_NAME"] ??= [];
$_SESSION["LDAP_NAME"][$username] = $ldap_name;
$_REQUEST['u'] = $username;
}
}
}
return $valid_password;
}
$row = $this->getUserDetails($username);
if ($row) {
$crypt_password = L\crawlCrypt($password, $row['PASSWORD']);
$valid_password = hash_equals($row['PASSWORD'],
$crypt_password);
} else {
/* Hash against a throwaway salt even though the user
is missing, so a bad username costs the same bcrypt
work as a real one and the response time does not
reveal whether the account exists. This constant
work, plus the constant-time hash_equals compare
above, is the timing-attack defence in place of an
older fixed-interval sleep that padded every sign-in
out to as much as a second. */
L\crawlCrypt($password);
$valid_password = false;
}
return $valid_password;
}
/**
* Given the email address an LDAP directory returned for a member,
* finds the Yioop account that owns it. Because LDAP identifies a
* member by email, this replaces the old LocalConfig
* LDAP_LOCAL_USER callback with a direct lookup in the accounts
* table. It returns the single matching username, or false when no
* account has the email or when more than one does. The Security
* activity refuses to switch a site to LDAP while two accounts share
* an email, so a duplicate should not arise; if one somehow does,
* returning false refuses the sign-in rather than guessing which
* account was meant.
*
* @param string $email the email the directory holds for the member
* @return string|bool the owning Yioop username, or false
*/
public function localUserForLdapEmail($email)
{
if (trim((string)$email) === "") {
return false;
}
$sql = "SELECT USER_NAME FROM USERS WHERE LOWER(EMAIL) = LOWER(?)";
$result = $this->db->execute($sql, [$email]);
$found = [];
if ($result) {
while (($row = $this->db->fetchArray($result)) !== false &&
count($found) <= 1) {
$found[] = $row['USER_NAME'];
}
}
if (count($found) == 1) {
return $found[0];
}
return false;
}
/**
* Finds every email address that more than one account uses, along
* with the usernames that share it. Because LDAP identifies a member
* by email, two accounts holding the same email are ambiguous under
* LDAP. The Security activity uses this to refuse turning LDAP on
* until an operator resolves the overlap, and to offer the list of
* clashes as a downloadable file. Accounts with no email are skipped
* since they are never matched against the directory.
*
* @return array map from each shared email to the list of usernames
* that have it; empty when no email is shared
*/
public function emailConflicts()
{
$sql = "SELECT EMAIL, USER_NAME FROM USERS " .
"WHERE EMAIL IS NOT NULL AND EMAIL <> '' " .
"AND LOWER(EMAIL) IN (SELECT LOWER(EMAIL) FROM USERS " .
"WHERE EMAIL IS NOT NULL AND EMAIL <> '' " .
"GROUP BY LOWER(EMAIL) HAVING COUNT(*) > 1) " .
"ORDER BY LOWER(EMAIL), USER_NAME";
$result = $this->db->execute($sql);
$conflicts = [];
if ($result) {
while ($row = $this->db->fetchArray($result)) {
$email = mb_strtolower($row['EMAIL']);
$conflicts[$email] ??= [];
$conflicts[$email][] = $row['USER_NAME'];
}
}
return $conflicts;
}
/**
* Reports whether any email address is held by more than one account.
* This is the quick yes-or-no the sign-in path and the Security panel
* need to decide if LDAP is usable; the fuller emailConflicts list is
* only built when the operator downloads it.
*
* @return bool true when at least one email is shared by two accounts
*/
public function hasEmailConflict()
{
$sql = "SELECT LOWER(EMAIL) AS SHARED FROM USERS " .
"WHERE EMAIL IS NOT NULL AND EMAIL <> '' " .
"GROUP BY LOWER(EMAIL) HAVING COUNT(*) > 1";
$result = $this->db->execute($sql);
$row = ($result) ? $this->db->fetchArray($result) : false;
return $row !== false && $row !== null;
}
/**
* Lists what is left to fix before LDAP can actually be used, given the
* three directory settings. An operator may choose and save LDAP at any
* time, but the site keeps signing members in with their local
* passwords until this list is empty: the sign-in path checks it on
* every attempt and the Security panel shows it so the operator knows
* what to resolve. The checks are: at least one directory server, an
* account suffix, a base DN, no email shared by two accounts (which
* LDAP could not tell apart), and an email on the root account (so an
* LDAP sign-in can still reach the administrator).
*
* @param string $controllers the comma-separated directory-server list
* @param string $account_suffix the LDAP account suffix setting
* @param string $base_dn the LDAP base DN setting
* @return array list of short problem tokens, empty when LDAP is ready
*/
public function ldapConfigProblems($controllers, $account_suffix,
$base_dn)
{
$problems = [];
if (trim((string)$controllers) === "") {
$problems[] = "no_servers";
}
if (trim((string)$account_suffix) === "") {
$problems[] = "no_suffix";
}
if (trim((string)$base_dn) === "") {
$problems[] = "no_base_dn";
}
if ($this->hasEmailConflict()) {
$problems[] = "email_conflicts";
}
$sql = "SELECT EMAIL FROM USERS WHERE USER_ID = ?";
$result = $this->db->execute($sql, [C\ROOT_ID]);
$row = ($result) ? $this->db->fetchArray($result) : false;
if (!$row || trim((string)$row['EMAIL']) === "") {
$problems[] = "root_no_email";
}
return $problems;
}
/**
* Checks that the root account can really sign in through the configured
* LDAP directory before the site switches over to it. It binds to the
* directory with the supplied root sign-in, reads the email the directory
* holds for that account, and confirms it matches the email on the local
* root account. This makes sure that turning LDAP on will not lock the
* administrator out of a directory that cannot actually authenticate
* them. The password is used only for this one check and is never stored.
*
* @param mixed $controllers one or more LDAP directory servers, either an
* array or a comma separated list of host names
* @param string $account_suffix text appended to a username to form the
* name the directory expects, for example an email domain
* @param string $base_dn the base distinguished name a directory search
* starts from
* @param string $root_name the root account's directory username
* @param string $password the root account's directory password
* @return string empty string when the bind succeeds and the directory
* email matches the local root email; "bind_failed" when the
* directory rejects the sign-in or cannot be reached; or
* "email_mismatch" when the directory email does not match the local
* root account's email
*/
public function ldapRootValidationProblem($controllers, $account_suffix,
$base_dn, $root_name, $password)
{
if (trim((string)$root_name) === "" || $password === "") {
return "bind_failed";
}
$controller_entries = (is_array($controllers)) ? $controllers :
array_values(array_filter(array_map('trim',
explode(',', (string)$controllers))));
if (empty($controller_entries)) {
return "bind_failed";
}
$bound = false;
$directory_email = "";
foreach ($controller_entries as $controller) {
$connection = @ldap_connect("ldaps://" . $controller);
if (!$connection) {
continue;
}
@ldap_start_tls($connection);
if (@ldap_bind($connection, $root_name . $account_suffix,
$password)) {
$bound = true;
$filter = "(&(objectCategory=person)(samaccountname=" .
$root_name . "))";
$results = @ldap_search($connection, $base_dn, $filter,
["mail"]);
if ($results) {
$entries = @ldap_get_entries($connection, $results);
$directory_email = $entries[0]['mail'][0] ?? "";
}
break;
}
}
if (!$bound) {
return "bind_failed";
}
$sql = "SELECT EMAIL FROM USERS WHERE USER_ID = ?";
$result = $this->db->execute($sql, [C\ROOT_ID]);
$row = ($result) ? $this->db->fetchArray($result) : false;
$root_email = ($row) ? trim((string)$row['EMAIL']) : "";
if ($root_email === "" || strtolower((string)$directory_email) !==
strtolower($root_email)) {
return "email_mismatch";
}
return "";
}
/**
* Get user details from database
*
* @param string $username username
* @return array $result array of user data
*/
public function getUserDetails($username)
{
$db = $this->db;
$sql = "SELECT USER_NAME, PASSWORD FROM USERS ".
"WHERE LOWER(USER_NAME) = LOWER(?) " . $db->limitOffset(1);
$i = 0;
do {
if ($i > 0) {
sleep(3);
}
$result = $db->execute($sql, [$username]);
$i++;
} while (!$result && $i < 2);
if (!$result) {
return false;
}
$row = $db->fetchArray($result);
return $row;
}
/**
* Get the user_name associated with a given userid
*
* @param string $user_id the userid to look up
* @return string the corresponding username
*/
public function getUserName($user_id)
{
$db = $this->db;
$sql = "SELECT USER_NAME FROM USERS WHERE USER_ID = ? " .
$db->limitOffset(1);
$result = $db->execute($sql, [$user_id]);
if ($row = $db->fetchArray($result)) {
$username = $row['USER_NAME'];
return mb_strtolower($username);
}
return false;
}
/**
* Changes the password of a given user
*
* @param string $username username of user to change password of
* @param string $password new password for user
* @return bool update successful or not.
*/
public function changePassword($username, $password)
{
$sql = "UPDATE USERS SET PASSWORD=? WHERE USER_NAME = ? ";
$result = $this->db->execute($sql,
[L\crawlCrypt($password), $username]);
return $result != false;
}
/**
* Stores the hash of a freshly issued one-time sign-in code for a
* user, replacing any code that user already had so only the most
* recent one works. The plaintext code is never stored: only its
* hash, the time it expires, and a zeroed wrong-guess counter.
*
* @param int $user_id id of the user the code was issued to
* @param string $code_hash hash of the sign-in code
* @param int $expires unix time after which the code stops working
*/
public function setSigninCode($user_id, $code_hash, $expires)
{
$sql = "DELETE FROM SIGNIN_CODE WHERE USER_ID = ?";
$this->db->execute($sql, [$user_id]);
$sql = "INSERT INTO SIGNIN_CODE VALUES (?, ?, ?, ?)";
$this->db->execute($sql, [$user_id, $code_hash, $expires, 0]);
}
/**
* Looks up the pending one-time sign-in code record for a user: the
* stored code hash, when it expires, and how many wrong guesses it
* has taken so far.
*
* @param int $user_id id of the user to look up
* @return array the row with CODE_HASH, EXPIRES, and TRIES, or an
* empty array when the user has no pending code
*/
public function getSigninCode($user_id)
{
$db = $this->db;
$sql = "SELECT CODE_HASH, EXPIRES, TRIES FROM SIGNIN_CODE " .
"WHERE USER_ID = ? " . $db->limitOffset(1);
$result = $db->execute($sql, [$user_id]);
if (!empty($result)) {
$row = $db->fetchArray($result);
if (!empty($row)) {
return $row;
}
}
return [];
}
/**
* Removes a user's pending one-time sign-in code, for example once it
* has been used to sign in, has expired, or has taken too many
* wrong guesses.
*
* @param int $user_id id of the user whose code should be removed
*/
public function deleteSigninCode($user_id)
{
$sql = "DELETE FROM SIGNIN_CODE WHERE USER_ID = ?";
$this->db->execute($sql, [$user_id]);
}
/**
* Records one more wrong guess against a user's pending sign-in code
* so it can be thrown away once too many have been made.
*
* @param int $user_id id of the user whose code was guessed at
*/
public function incrementSigninCodeTries($user_id)
{
$sql = "UPDATE SIGNIN_CODE SET TRIES = TRIES + 1 " .
"WHERE USER_ID = ?";
$this->db->execute($sql, [$user_id]);
}
/**
* Gives what one section of a ballot's files holds. Both the secrets
* file and the file a round waits in are a run of sections, each
* opened by a line of dashes naming it and ended by the next such
* line.
*
* @param string $filepath the file to read from
* @param string $mark the line opening the section wanted
* @return string what the section holds, empty where there is none
*/
public function readSection($filepath, $mark)
{
if (!file_exists($filepath)) {
return "";
}
$said = file_get_contents($filepath);
$at = strpos($said, $mark);
if ($at === false) {
return "";
}
$section = substr($said, $at + strlen($mark));
$ends = strpos($section, "-----");
return ($ends === false) ? $section : substr($section, 0, $ends);
}
/**
* Puts what one section of a ballot's files holds, adding the
* section where the file has none and replacing it where it has.
* Every other section is left as it was.
*
* @param string $filepath the file to write to
* @param string $mark the line opening the section
* @param string $holds what the section is to hold
*/
public function writeSection($filepath, $mark, $holds)
{
$said = file_exists($filepath) ?
file_get_contents($filepath) : "";
$at = strpos($said, $mark);
if ($at === false) {
$said .= $mark . $holds;
} else {
$ends = strpos($said, "-----", $at + strlen($mark));
$said = substr($said, 0, $at) . $mark . $holds .
(($ends === false) ? "" : substr($said, $ends));
}
file_put_contents($filepath, $said);
}
/**
* Keeps one witness's part of a ballot until every witness has taken
* theirs. What is kept is the two values the ballot's key is later
* built from, sealed with the witness round's public key so that only
* the secret key in the round's own file opens them again.
*
* A witness acting by mail is not at the form when the others are, so
* their part has to wait somewhere. It waits sealed, and the secret
* that opens it is in a file nobody but the site's own user may read.
*
* @param string $round_filepath the file the round waits in
* @param string $witness whose part this is
* @param string $public the round's public key
* @param string $random the random value drawn for this witness
* @param string $hash the value the ballot's key is built from
*/
public function keepWitnessShare($round_filepath, $witness, $public,
$random, $hash)
{
$sealed = sodium_crypto_box_seal(base64_encode($random) . " " .
base64_encode($hash), $public);
$shares = $this->witnessSharesKept($round_filepath);
$shares[$witness] = base64_encode($sealed);
$this->writeWitnessShares($round_filepath, $shares);
}
/**
* Opens every part a ballot has been keeping, using the secret key in
* the witness round's file.
*
* @param string $round_filepath the file the round waits in
* @param string $page_hash the ballot's form hash
* @return array for each witness, the random value and the value the
* ballot's key is built from
*/
public function openWitnessShares($round_filepath, $page_hash)
{
$key_file = $this->witnessRoundKeyFile($page_hash);
if (!file_exists($key_file)) {
return [];
}
/* Reading the key here is the site's own doing and must not look
like somebody else's, so the file is left with the times it had
before this read. */
clearstatcache(true, $key_file);
$was_read = fileatime($key_file);
$was_changed = filemtime($key_file);
$secret = base64_decode(file_get_contents($key_file));
touch($key_file, $was_changed, $was_read);
clearstatcache(true, $key_file);
$kept = $this->witnessRoundKept($round_filepath);
$pair = sodium_crypto_box_keypair_from_secretkey_and_publickey(
$secret, $kept["public"]);
$opened = [];
foreach ($this->witnessSharesKept($round_filepath)
as $witness => $sealed) {
$said = @sodium_crypto_box_seal_open(base64_decode($sealed),
$pair);
if ($said === false) {
continue;
}
$parts = explode(" ", $said, 2);
if (count($parts) == 2) {
$opened[$witness] = ["random" => base64_decode($parts[0]),
"hash" => base64_decode($parts[1])];
}
}
return $opened;
}
/**
* Makes a ballot's pair of keys from the parts every witness has
* contributed, once the last of them has arrived.
*
* This is the moment the old form reaches when the last witness types
* their password: every witness's value is in memory at once and the
* ballot's key is built from them. Here the values have been waiting
* sealed instead, so they are opened first, and only if nothing has
* read the key file that opens them. A round whose key file has been
* read gives nothing back, because the parts could have been taken
* and the ballot has to be begun again.
*
* @param string $round_filepath the file the round waits in
* @param string $page_hash the ballot's form hash
* @param array $witnesses the witnesses, in the order they are named
* @return mixed the random values, the public key, the secret key,
* the seed, as the form's own way gives them back, and the
* value each witness's password made, or false where the round
* cannot be finished. Those last are what the ballot's key has
* to be built from: built from anything else, counting the
* ballot later cannot rebuild it.
*/
public function finishWitnessRound($round_filepath, $page_hash,
$witnesses)
{
if (!$this->witnessRoundIsIntact($round_filepath, $page_hash)) {
return false;
}
$parts = $this->openWitnessShares($round_filepath, $page_hash);
$randoms = [];
$hashes = [];
foreach ($witnesses as $witness) {
if (empty($parts[$witness])) {
return false;
}
$randoms[] = $parts[$witness]["random"];
$hashes[] = $parts[$witness]["hash"];
}
$made_from = $hashes;
$seed = random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES);
$eval_poly = "\x01";
foreach ($hashes as $hash) {
if ($hash == $seed) {
return false;
}
list(, $diff) = L\bigSubtract($seed, $hash);
$eval_poly = L\bigMultiply($eval_poly, $diff);
}
$randoms[count($witnesses)] = $seed;
$hash_poly = substr(hash('sha256', $eval_poly, true), 0,
SODIUM_CRYPTO_SIGN_SEEDBYTES);
$keypair = sodium_crypto_box_seed_keypair($hash_poly);
return [$randoms, sodium_crypto_box_publickey($keypair),
sodium_crypto_box_secretkey($keypair), $hash_poly,
$made_from];
}
/**
* Puts a witness round beyond reach once it has done its work: the
* file holding its secret key is deleted, and the shares and the
* round's own section are taken out of the file the round waits in.
*
* @param string $round_filepath the file the round waits in
* @param string $page_hash the ballot's form hash
*/
public function closeWitnessRound($round_filepath, $page_hash)
{
$key_file = $this->witnessRoundKeyFile($page_hash);
if (file_exists($key_file)) {
unlink($key_file);
}
/* The round's file holds nothing but the round, so it goes
whole rather than section by section. */
if (file_exists($round_filepath)) {
unlink($round_filepath);
}
}
/**
* Gives the random value a ballot drew for one witness when it was
* started. Counting has to work out the value that witness's password
* makes from this same one, or the key it rebuilds will not match the
* one the ballot was made with.
*
* @param string $secrets_filepath the ballot's secrets file
* @param mixed $at which witness, counting from zero, or false where
* the witness is not one of this ballot's
* @return mixed the value drawn for them, or false where there is
* none
*/
public function witnessSeedKept($secrets_filepath, $at)
{
if ($at === false || $at < 0) {
return false;
}
$seeds = explode("\n", trim($this->readSection($secrets_filepath,
"-----SEEDS-----\n")));
if (!isset($seeds[$at])) {
return false;
}
return base64_decode(trim($seeds[$at]));
}
/**
* Gives the parts a ballot is keeping, by witness, each still sealed.
*
* @param string $round_filepath the file the round waits in
* @return array the sealed parts, keyed by witness
*/
public function witnessSharesKept($round_filepath)
{
$section = $this->readSection($round_filepath,
self::BALLOT_SHARES_MARK);
$shares = [];
foreach (explode("\n", trim($section)) as $line) {
$parts = explode(" ", trim($line), 2);
if (count($parts) == 2 && $parts[0] !== "") {
$shares[base64_decode($parts[0])] = $parts[1];
}
}
return $shares;
}
/**
* Writes the parts a ballot is keeping into the file its round waits
* in.
*
* @param string $round_filepath the file the round waits in
* @param array $shares the sealed parts, keyed by witness
*/
private function writeWitnessShares($round_filepath, $shares)
{
$holds = "";
foreach ($shares as $witness => $sealed) {
/* The witness's name is written encoded so that a name
holding a space or a line break cannot break the file
apart. */
$holds .= base64_encode($witness) . " " . $sealed . "\n";
}
$this->writeSection($round_filepath, self::BALLOT_SHARES_MARK,
$holds);
}
/**
* Gives the file a witness round's secret key is kept in while the
* round is open. It is named from the ballot's own hash and the day,
* so a round begun on another day uses another file. It lives among
* the temporary files rather than the data, since the data is the
* part of a site that gets backed up and a copy of this key taken off
* the machine would outlive the round it belongs to.
*
* @param string $page_hash the ballot's form hash
* @param int $when the moment naming the day, now by default
* @return string the path of the key file
*/
public function witnessRoundKeyFile($page_hash, $when = 0)
{
$when = ($when > 0) ? $when : time();
return C\TEMP_DIR . "/" . hash('sha256', $page_hash .
date("Y-m-d", $when)) . ".txt";
}
/**
* Says whether reading a file where the round's key file goes moves
* that file's access time. Where it does not, the check that says
* whether anything read the key can never find a reader, and the
* person starting a ballot should be told so.
*
* This is asked of the file system rather than worked out from how it
* was mounted: a file is made where the key file would go, its access
* time noted, its contents read, and the access time looked at again.
* A file system that does not move an access time on a read, however
* it came to be set up and whatever operating system it is under,
* answers the same way.
*
* @return string "watched" where a read moves the access time,
* "unwatched" where it does not, and "unknown" where the
* question cannot be put
*/
public function keyFileWatchfulness()
{
$probe = C\TEMP_DIR . "/" . hash('sha256', "atime probe" .
random_bytes(8)) . ".txt";
if (@file_put_contents($probe, "probe") === false) {
return "unknown";
}
/* The probe is set up the way a round's key file is: its access
time behind its change time, which is the case where a file
system that moves an access time only when it lags will move
it. Setting both back together would answer for a case the key
file is never in. */
@touch($probe, time(), time() - self::KEY_FILE_READ_WINDOW);
clearstatcache(true, $probe);
$before = @fileatime($probe);
@file_get_contents($probe);
clearstatcache(true, $probe);
$after = @fileatime($probe);
@unlink($probe);
if ($before === false || $after === false) {
return "unknown";
}
return ($after > $before) ? "watched" : "unwatched";
}
/**
* Opens a witness round: makes the pair of keys the round's shares
* are sealed with, keeps the secret one in a file only the site's own
* user may read, and writes the public one into the ballot's secrets
* file together with the moment that file was last read.
*
* The recorded times are what say afterwards whether anything was at
* the secret key while the round was open. Reading the file moves its
* access time; writing to it moves the time it was last changed. A
* file system may be set up not to move an access time on a read, in
* which case a reader passes unseen, but a change to the file is
* still caught.
*
* @param string $round_filepath the file the round waits in
* @param string $page_hash the ballot's form hash
* @return string the public key the round's shares are sealed with
*/
public function openWitnessRound($round_filepath, $page_hash)
{
$key_file = $this->witnessRoundKeyFile($page_hash);
$pair = sodium_crypto_box_keypair();
$secret = sodium_crypto_box_secretkey($pair);
$public = sodium_crypto_box_publickey($pair);
file_put_contents($key_file, base64_encode($secret));
chmod($key_file, 0600);
/* A freshly written file has the same access time as change
time, and several file systems, among them the one macOS uses
by default and Linux mounted the usual way, move an access time
on a read only when it is already older than the change time.
Left alone, this file would never report having been read. Its
access time is therefore set back behind its change time, so
the first read moves it and is seen. */
touch($key_file, time(), time() - self::KEY_FILE_READ_WINDOW);
clearstatcache(true, $key_file);
$this->writeSection($round_filepath, self::BALLOT_ROUND_MARK,
base64_encode($public) . "\n" . fileatime($key_file) . "\n" .
filemtime($key_file) . "\n");
return $public;
}
/**
* Gives the public key a witness round's shares are sealed with, and
* the access time its key file had when the round opened.
*
* @param string $round_filepath the file the round waits in
* @return array the public key, the access time and the time the
* file was last changed, all empty where no round is open
*/
public function witnessRoundKept($round_filepath)
{
$section = $this->readSection($round_filepath,
self::BALLOT_ROUND_MARK);
$lines = explode("\n", trim($section));
if (count($lines) < 3) {
return ["public" => "", "atime" => 0, "mtime" => 0];
}
return ["public" => base64_decode(trim($lines[0])),
"atime" => (int)trim($lines[1]),
"mtime" => (int)trim($lines[2])];
}
/**
* Says whether a witness round is still worth finishing: whether its
* key file is there, and whether anything has read it since the round
* opened. A round whose key file has been read is one where the
* shares could have been opened by somebody other than the site, so
* the ballot has to be begun again.
*
* Two things limit what this can promise. A file system may not move
* an access time on a read at all, in which case a plain copy of the
* key passes unseen, though a change to it is still caught. And a
* reader who can write to the file can put both times back
* afterwards. So a round this calls untouched may still have been
* read, while a round it calls touched certainly has been.
*
* @param string $round_filepath the file the round waits in
* @param string $page_hash the ballot's form hash
* @return bool whether the round is untouched
*/
public function witnessRoundIsIntact($round_filepath, $page_hash)
{
$key_file = $this->witnessRoundKeyFile($page_hash);
if (!file_exists($key_file)) {
return false;
}
clearstatcache(true, $key_file);
$kept = $this->witnessRoundKept($round_filepath);
if ($kept["atime"] <= 0 || $kept["mtime"] <= 0) {
return false;
}
/* Reading the file moves its access time, and writing to it moves
the time it was last changed. Either moving means somebody has
been at the key, so both are compared: a file system that will
not report a read still reports a change. */
return fileatime($key_file) == $kept["atime"] &&
filemtime($key_file) == $kept["mtime"];
}
/**
* Gives the nonce the ballot's current invitations were made with,
* making one where the ballot has none yet.
*
* @param string $round_filepath the file the round waits in
* @param bool $afresh whether to make a new one, which ends every
* invitation sent before now
* @return string the nonce
*/
public function ballotInviteNonce($round_filepath, $afresh = false)
{
$kept = trim($this->readSection($round_filepath,
self::BALLOT_NONCE_MARK));
if ($kept !== "" && !$afresh) {
return $kept;
}
$nonce = bin2hex(random_bytes(16));
$this->writeSection($round_filepath, self::BALLOT_NONCE_MARK,
$nonce . "\n");
return $nonce;
}
/**
* Says when each witness was last written to, as a map from the
* witness's name to a time. A witness with nothing written down is
* not in the map.
*
* @param string $round_filepath the file the round waits in
* @return array witness name => when they were last written to
*/
public function witnessInvitesNoted($round_filepath)
{
$noted = trim($this->readSection($round_filepath,
self::BALLOT_INVITED_MARK));
if ($noted === "") {
return [];
}
$times = json_decode($noted, true);
return is_array($times) ? $times : [];
}
/**
* Writes down that a witness has just been written to.
*
* @param string $round_filepath the file the round waits in
* @param string $witness whose invitation went
* @param int $when the time it went
* @return void
*/
public function noteWitnessInvited($round_filepath, $witness, $when)
{
$times = $this->witnessInvitesNoted($round_filepath);
$times[$witness] = $when;
$this->writeSection($round_filepath, self::BALLOT_INVITED_MARK,
json_encode($times) . "\n");
}
/**
* Whether a witness was written to so recently that another
* invitation would be a repeat rather than something asked for.
* The button that sends them is pressed once, and a request that
* reaches the site twice would otherwise mint a fresh nonce and
* end the invitation the first one sent, leaving the witness
* holding a link the site no longer knows.
*
* @param string $round_filepath the file the round waits in
* @param string $witness the witness being written to
* @param int $wait how many seconds count as too soon
* @return bool whether an invitation went to them within that time
*/
public function witnessInvitedJustNow($round_filepath, $witness,
$wait)
{
$times = $this->witnessInvitesNoted($round_filepath);
$last = (int) ($times[$witness] ?? 0);
return ($last > 0 && (time() - $last) < $wait);
}
/**
* Says why a link sent to a witness cannot be followed, or gives back
* the empty string where it can. A witness follows their link in
* their own time, so by then it may have grown old, a fresher set of
* invitations may have replaced it, or that witness may already have
* taken their part at the form.
*
* Having the whole decision here rather than at the screen lets each
* way of turning a link away be checked on its own. Already having
* acted is looked at first, since somebody following their own link a
* second time should be told that rather than that their link is bad.
*
* @param string $round_filepath the file the round waits in
* @param string $said the token the link carries
* @param int $page_id which page the ballot is on
* @param string $ballot_id which ballot on that page
* @param string $witness whose link it is
* @param string $stage which stage the link is for, start or count
* @param int $expires when the link stops being good
* @return string LINK_TAKEN, LINK_EXPIRED, LINK_BAD, or the empty
* string where the link may be followed
*/
public function witnessLinkRefusal($round_filepath, $said, $page_id,
$ballot_id, $witness, $stage, $expires)
{
$kept = $this->witnessSharesKept($round_filepath);
if (!empty($kept[$witness])) {
return self::LINK_TAKEN;
}
if ($expires < time()) {
return self::LINK_EXPIRED;
}
$nonce = $this->ballotInviteNonce($round_filepath);
if (!$this->ballotInviteIsGood($said, $page_id, $ballot_id,
$witness, $stage, $expires, $nonce)) {
return self::LINK_BAD;
}
return "";
}
/**
* Makes the token that stands for one invitation to take part in a
* ballot. A witness is sent a link carrying this, follows it in their
* own time, and takes their part without every witness having to be
* at the same form at the same moment.
*
* The token says which page and ballot it is for, which witness it
* was sent to, which stage of the ballot it is for, when it stops
* being good, and a nonce that makes each one different. Those are
* signed with the site's own key, so a token cannot be made by
* anybody who cannot read that key, and cannot be altered to name a
* different witness or a later expiry.
*
* @param int $page_id which page holds the ballot
* @param string $ballot_id which ballot on that page
* @param string $witness whose invitation this is
* @param string $stage which stage it is for, "start" or "count"
* @param int $expires when it stops being good, as a timestamp
* @param string $nonce what makes this invitation different from the
* last one sent to the same witness
* @return string the token, safe to put in a web address
*/
public function ballotInviteToken($page_id, $ballot_id, $witness,
$stage, $expires, $nonce)
{
return L\crawlAuthHash(implode("|", [$page_id, $ballot_id,
$witness, $stage, $expires, $nonce]));
}
/**
* Says whether an invitation a witness has followed is one this site
* sent, is for the stage it claims, and has not stopped being good.
*
* The token is worked out again from what the link carries and
* compared against what came back, so a changed page, ballot,
* witness, stage or expiry gives a different token and is refused.
* The comparison takes the same time whatever the difference, so
* nothing can be learnt by trying tokens one character at a time.
*
* @param string $said the token the link carried
* @param int $page_id which page the link names
* @param string $ballot_id which ballot the link names
* @param string $witness which witness the link names
* @param string $stage which stage the link names
* @param int $expires when the link says it stops being good
* @param string $nonce what the link carries to make it its own
* @param int $now the moment to judge the expiry against
* @return bool whether the invitation is good
*/
public function ballotInviteIsGood($said, $page_id, $ballot_id,
$witness, $stage, $expires, $nonce, $now = 0)
{
$now = ($now > 0) ? $now : time();
if ($expires <= $now) {
return false;
}
return hash_equals($this->ballotInviteToken($page_id, $ballot_id,
$witness, $stage, $expires, $nonce), (string)$said);
}
/**
* Creates a ballot file containing seeds, public key, and encrypted form
* hash.
*
* @param string $secrets_filepath - path where the ballot file will be
* written.
* @param string $form_hash - the hash of the form to be encrypted and
* stored.
* @param array $witnesses - list of witness identifiers used for key
* generation.
* @param array $passwords - passwords corresponding to each witness.
* @param array $random_vec - optional predefined random seed values;
* generated if not provided.
* @param array $hashes - the value each witness's password makes,
* already worked out, for witnesses who gave their password
* earlier and are not here to give it again. Where these are
* missing the ballot's key is built from empty passwords, and
* counting it later with the real ones cannot rebuild that key.
* @param bool $return_string - if true, returns the file contents as a
* string instead of writing to disk.
* @return string|void - returns the ballot file contents as a string if
* $return_string is true.
*/
public function createBallotFile($secrets_filepath, $form_hash,
$witnesses, $passwords, $random_vec = [], $hashes = [],
$return_string = false)
{
$secrets_data = "-----SEEDS-----\n";
list($random_vec, $public_key, , $hash_poly) =
$this->createWitnessKeyPair($witnesses,
$passwords, $random_vec, $hashes);
foreach ($random_vec as $random) {
$secrets_data .= base64_encode($random) . "\n";
}
$secrets_data .= "-----KEY-----\n" .
chunk_split(base64_encode($public_key), 64, "\n") .
"-----HASH-----\n" .
chunk_split(base64_encode(sodium_crypto_box_seal(
$form_hash, $public_key)), 64, "\n");
if ($return_string) {
return $secrets_data;
}
file_put_contents($secrets_filepath, $secrets_data);
}
/**
* Reads a ballot file, verifies the witness key pair and form hash, then
* decrypts and tallies votes.
*
* @param string $secrets_filepath - path to the ballot file to be counted.
* @param string $csv_filepath - path to the CSV file the per-vote
* tallies are written to after decryption
* @param string $form_hash - the expected form hash to verify against the
* stored encrypted hash.
* @param array $witnesses - list of witness identifiers used to
* reconstruct the key pair.
* @param array $passwords - passwords corresponding to each witness.
* @return string returns "KEY_MISMATCH" if the reconstructed key does
* not match, returns "FORM_MISMATCH" if the decrypted hash does not match
* $form_hash; otherwise, if the methods succeeds in counting the ballots
* it returns "SUCCESS"
* @param array $hashes for a witness who gave their password
* elsewhere, the value that password makes, worked out already
*/
public function countBallotFile($secrets_filepath, $csv_filepath,
$form_hash, $witnesses, $passwords, $hashes = [])
{
$return_message = "SUCCESS";
$secrets_data = $this->readParseVotefile($secrets_filepath);
$pre_seeds = explode("\n", $secrets_data['SEEDS']);
$seeds = [];
foreach ($pre_seeds as $pre_seed) {
$seeds[] = base64_decode(trim($pre_seed));
}
$pre_voters = explode("\n", ($secrets_data['VOTERS'] ?? ""));
$voters = [];
foreach ($pre_voters as $pre_voter) {
$voters[] = base64_decode($pre_voter);
}
$file_public_key = str_replace("\n", "", $secrets_data['KEY']);
list($random_vec, $public_key, $private_key, $hash_poly) =
$this->createWitnessKeyPair($witnesses,
$passwords, $seeds, $hashes);
if (base64_encode($public_key) != $file_public_key) {
return self::KEY_MISMATCH;
}
$keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey(
$private_key, $public_key);
$encrypt_form_hash = base64_decode(
str_replace("\n", "", ($secrets_data['HASH'] ?? "")));
$decrypt_form_hash = sodium_crypto_box_seal_open($encrypt_form_hash,
$keypair);
if ($decrypt_form_hash != $form_hash) {
$return_message = "FORM_MISMATCH";
}
$encrypt_votes = $secrets_data['VOTE'] ?? [];
$vote_issues = $secrets_data['VOTE_ISSUES'] ?? "";
$vote_issues = explode("\n", $vote_issues);
$votes = [];
$csv_headers = [];
foreach ($encrypt_votes as $encrypt_vote) {
$vote_string = sodium_crypto_box_seal_open(base64_decode(
str_replace("\n", "", $encrypt_vote)),
$keypair);
list($secret_form_hash, $vote_receipt,
$encoded_vote_data) = explode("\n", $vote_string);
$vote_data = unserialize(base64_decode($encoded_vote_data));
/* Each vote carries the hash of the ballot it was cast
against, so a reader can hold them up against the ballot
as it finished and see that nothing changed underneath
them. */
$vote = ["VOTE_BALLOT_HASH" => $secret_form_hash,
"RECEIPT" => $vote_receipt];
$i = 0;
foreach ($vote_issues as $vote_issue) {
$vote["ISSUE_" . $vote_issue] = $vote_data[$i];
$i++;
}
if (empty($csv_headers)) {
$csv_headers = array_keys($vote);
}
$votes[$vote_receipt] = $vote;
}
ksort($votes);
$votes = array_values($votes);
$fh = fopen($csv_filepath, "w+");
fputcsv($fh, $csv_headers, escape: "\\");
foreach ($votes as $vote) {
$out_row = array_values($vote);
fputcsv($fh, $out_row, escape: "\\");
}
$blank_row = array_fill(0, count($csv_headers), "");
fputcsv($fh, $blank_row, escape: "\\");
$hash_row = $blank_row;
$hash_row[0] = "FINAL_BALLOT_HASH";
fputcsv($fh, $hash_row, escape: "\\");
$hash_row[0] = $form_hash;
fputcsv($fh, $hash_row, escape: "\\");
fputcsv($fh, $blank_row, escape: "\\");
$voter_row = $blank_row;
$voter_row[0] = "VOTERS";
fputcsv($fh, $voter_row, escape: "\\");
foreach ($voters as $voter) {
$voter_row[0] = $voter;
fputcsv($fh, $voter_row, escape: "\\");
}
fclose($fh);
return $return_message;
}
/**
* Generates a witness key pair using a secret sharing polynomial evaluated
* over witness credentials.
*
* Derives a shared seed by combining per-witness random values,
* identifiers, and passwords via SHA-256 hashing, then uses the evaluated
* polynomial result to produce a libsodium box keypair.
*
* @param array $witnesses - list of witness identifiers.
* @param array $passwords - passwords corresponding to each witness.
* @param array $random_vec - optional predefined random seed values;
* missing entries are generated randomly.
* @return array a four-element array: [$random_vec, $public_key,
* $private_key, $hash_poly].
* @param array $hashes for a witness who gave their password
* elsewhere, the value that password makes, worked out already
*/
public function createWitnessKeyPair($witnesses, $passwords,
$random_vec = [], $hashes = [])
{
$num_witnesses = count($witnesses);
do {
$seed = isset($random_vec[$num_witnesses]) ?
$random_vec[$num_witnesses] :
random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES);
$eval_poly = "\x01";
$is_bad_seed = false;
for ($i = 0; $i < $num_witnesses; $i++) {
$not_predefined = !isset($random_vec[$i]);
$random_vec[$i] = ($not_predefined) ?
random_bytes(SODIUM_CRYPTO_SIGN_SEEDBYTES) :
$random_vec[$i];
$random = $random_vec[$i];
/* A witness who gave their password by mail is not here
to give it again, so the value it makes may be handed
in already worked out. */
$hash = isset($hashes[$i]) ? $hashes[$i] :
hash('sha256', $random . $witnesses[$i] .
$passwords[$i], true);
if ($not_predefined && $hash == $seed) {
$is_bad_seed = true;
break;
}
/* bigSubtract gives back a sign and a size; only the
size is wanted here. */
list(, $diff) = L\bigSubtract($seed, $hash);
$eval_poly = L\bigMultiply($eval_poly, $diff);
}
} while ($is_bad_seed);
$random_vec[$num_witnesses] = $seed;
$hash_poly = substr(hash('sha256', $eval_poly, true), 0,
SODIUM_CRYPTO_SIGN_SEEDBYTES);
$keypair = sodium_crypto_box_seed_keypair($hash_poly);
return [$random_vec, sodium_crypto_box_publickey($keypair),
sodium_crypto_box_secretkey($keypair), $hash_poly];
}
/**
* Encrypts and appends a vote to the ballot file, preventing duplicate
* votes per session user.
*
* @param string $secrets_filepath - path to the ballot file.
* @param string $secret_form_hash - the form hash to be bundled with the
* encrypted vote.
* @param array $vote_issues - the questions asked, in the order the
* answers are given, one name for each answer
* @param mixed $vote - the vote data to serialize and encrypt.
* @return array a two-element array [$message, $vote_receipt],
* where $message is one of: "SUCCESS", "FILE_CORRUPTED", or
* "ALREADY_VOTED", and $vote_receipt is a base64-encoded random receipt
* string (empty string on failure).
*/
public function addVote($secrets_filepath, $secret_form_hash, $vote_issues,
$vote)
{
$message = "SUCCESS";
$secrets_data = $this->readParseVotefile($secrets_filepath);
if (empty($secrets_data["SEEDS"]) || empty($secrets_data["KEY"]) ||
empty($secrets_data["HASH"])) {
$message = "FILE_CORRUPTED";
return [$message, ""];
}
$voters = explode("\n", ($secrets_data["VOTERS"] ?? ""));
$user = base64_encode($_SESSION['USER_NAME']);
if (in_array($user, $voters)) {
$message = "ALREADY_VOTED";
return [$message, ""];
}
$voters[] = $user;
$voters = array_filter($voters);
sort($voters);
$secrets_data["VOTERS"] = implode("\n", $voters);
if (empty($secrets_data["VOTE_ISSUES"])) {
/* These names say what each value in a vote stands for, in
the order the vote holds them. They arrive already
matched to the answers, since working them out here as
well left the two lists disagreeing about which fields
count and moved every answer a place along. */
$secrets_data["VOTE_ISSUES"] = implode("\n",
array_values($vote_issues));
}
$public_key =
base64_decode(trim(preg_replace('/\s+/', '',
$secrets_data["KEY"])));
$vote_receipt = base64_encode(random_bytes(
SODIUM_CRYPTO_SIGN_SEEDBYTES));
$plaintext_vote = $secret_form_hash . "\n" .
$vote_receipt . "\n" .
base64_encode(serialize($vote));
$encrypt_vote = chunk_split(base64_encode(sodium_crypto_box_seal(
$plaintext_vote, $public_key)), 64, "\n");
$secrets_data["VOTE"][] = $encrypt_vote;
$out_file = "";
foreach ($secrets_data as $field => $data) {
if (is_string($data)) {
$data = [$data];
}
foreach ($data as $item) {
$out_file .= "-----$field-----\n" .
rtrim($item) . "\n";
}
}
file_put_contents($secrets_filepath, $out_file);
return [$message, $vote_receipt];
}
/**
* Reads and parses a ballot file into a structured associative array
* keyed by section name.
*
* Sections are delimited by "-----SECTION_NAME-----" headers. Multiple
* VOTE sections are collected into an array; all other sections store only
* their last occurrence.
*
* @param string $secrets_filepath - path to the ballot file to read and
* parse.
* @return array associative array of section names to their content
* strings,
* except "VOTE" which maps to an array of encrypted vote strings.
*/
public function readParseVotefile($secrets_filepath)
{
$pre_secrets_data = file_get_contents($secrets_filepath);
$separator = "/-----([^-]+)-----\n/";
$secrets_parts = preg_split($separator, $pre_secrets_data);
array_shift($secrets_parts);
preg_match_all("/-----([^-]+)-----\n/", $pre_secrets_data,
$part_names);
$part_names = $part_names[1];
$secrets_data = [];
$i = 0;
foreach ($part_names as $part_name) {
$secrets_data[$part_name] ??= [];
if ($part_name == "VOTE") {
$secrets_data["VOTE"][] = rtrim($secrets_parts[$i]);
} else {
$secrets_data[$part_name] = rtrim($secrets_parts[$i]);
}
$i++;
}
return $secrets_data;
}
}