<?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
{
/**
* 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 "";
}
/**
* Reports whether some account other than the named one already has
* the given email. Used to keep emails unique while a site runs on
* LDAP, where a shared email would be ambiguous. An empty email is
* never treated as in use.
*
* @param string $email the email address to look for
* @param string $exclude_username the account allowed to hold it
* @return bool true when a different account already has the email
*/
public function emailInUseByOtherUser($email, $exclude_username)
{
if (trim((string)$email) === "") {
return false;
}
$sql = "SELECT COUNT(*) AS NUM FROM USERS " .
"WHERE LOWER(EMAIL) = LOWER(?) AND USER_NAME <> ?";
$result = $this->db->execute($sql, [$email, $exclude_username]);
$row = ($result) ? $this->db->fetchArray($result) : false;
return $row && $row['NUM'] > 0;
}
/**
* 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;
}
/**
* Checks that a username email pair is valid
*
* @param string $username the username to check
* @param string $email the email to check
* @return bool where the email is that of the given user
* (or at least hashes to the same thing)
*/
public function checkValidEmail($username, $email)
{
$db = $this->db;
$sql = "SELECT USER_NAME, EMAIL FROM USERS ".
"WHERE LOWER(USER_NAME) = LOWER(?) " . $db->limitOffset(1);
$result = $db->execute($sql, [$username]);
if (!$result) {
return false;
}
$row = $db->fetchArray($result);
return email == $row['EMAIL'];
}
/**
* 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;
}
/**
* Get the email associated with a given user_id
*
* @param string $user_id the userid to look up
* @return string the corresponding email
*/
public function getEmail($user_id)
{
$db = $this->db;
$sql = "SELECT EMAIL FROM USERS WHERE
USER_ID = ? " . $db->limitOffset(1);
$result = $db->execute($sql, [$user_id]);
$row = $db->fetchArray($result);
$email = mb_strtolower($row['EMAIL']);
return $email;
}
/**
* Changes the email of a given user
*
* @param string $username username of user to change email of
* @param string $email new email for user
* @return bool update successful or not.
*/
public function changeEmail($username, $email)
{
if (C\p('AUTH_METHOD') == C\LDAP_AUTHENTICATION &&
$this->emailInUseByOtherUser($email, $username)) {
return false;
}
$sql = "UPDATE USERS SET EMAIL= ? WHERE USER_NAME = ? ";
$result = $this->db->execute($sql, [mb_strtolower($email), $username]);
return $result != 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]);
}
/**
* 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 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 = [], $return_string = false)
{
$secrets_data = "-----SEEDS-----\n";
list($random_vec, $public_key, , $hash_poly) =
$this->createWitnessKeyPair($witnesses,
$passwords, $random_vec);
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");
"-----VOTERS-----\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"
*/
public function countBallotFile($secrets_filepath, $csv_filepath,
$form_hash, $witnesses, $passwords)
{
$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);
if (base64_encode($public_key) != $file_public_key) {
return "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));
$vote = ["FORM_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] = "FORM_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].
*/
public function createWitnessKeyPair($witnesses, $passwords,
$random_vec = [])
{
$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];
$hash = hash('sha256', $random . $witnesses[$i] .
$passwords[$i], true);
if ($not_predefined && $hash == $seed) {
$is_bad_seed = true;
break;
}
list(, $diff) = L\bigSubtract($seed, $hash);
//drops sign
$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 - an associative area of field names for
* each issue being voted on
* @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"])) {
$issues = [];
foreach ($vote_issues as $vote_issue => $type) {
if (!in_array($vote_issue, ["require_signin",
"user_captcha_text" ]) && $type != "submit" ) {
$issues[] = $vote_issue;
}
}
$secrets_data["VOTE_ISSUES"] = implode("\n", $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;
}
}